Skip to content

fix: Chatwoot integration drops templateMessage, conversation never created - #2666

Open
pbarbagli wants to merge 4 commits into
evolution-foundation:mainfrom
pbarbagli:fix/chatwoot-templatemessage-body
Open

fix: Chatwoot integration drops templateMessage, conversation never created#2666
pbarbagli wants to merge 4 commits into
evolution-foundation:mainfrom
pbarbagli:fix/chatwoot-templatemessage-body

Conversation

@pbarbagli

@pbarbagli pbarbagli commented Jul 29, 2026

Copy link
Copy Markdown

getTypeMessage() doesn't have a case for templateMessage, so when a
contact's first message arrives as a WhatsApp Business template (common
for B2B outreach sent via WhatsApp Business Platform), no content is
extracted, eventWhatsapp logs "no body message found" and returns
before ever calling createConversation — the conversation is silently
dropped, and only gets created later if/when a normal-type message
follows (e.g. a reply).

This adds hydratedTemplate.hydratedContentText extraction, mirroring
how templateMessage is already handled in the other
getContentMessage() fallback path a few lines below, which just isn't
used on this ingestion path.

Fixes the templateMessage item tracked in #2014.
Duplicate of the (closed) #2500.

Tested against a real production instance: reproduced the exact
"no body message found" log with a live inbound templateMessage,
applied this one-line fix, confirmed the conversation now gets created
correctly (as incoming) instead of being dropped.

Summary by Sourcery

Bug Fixes:

  • Extract content from WhatsApp templateMessage hydratedTemplate.hydratedContentText to avoid dropping conversations when the first inbound message is a template.

…reated

getTypeMessage() has no case for templateMessage (WhatsApp Business
template), so the first message from a contact using this type produces
no body, eventWhatsapp logs 'no body message found' and returns before
calling createConversation. The conversation only gets created later if
a normal-type message follows.

Extracts hydratedTemplate.hydratedContentText, mirroring how
templateMessage is already handled in the getContentMessage() fallback
a few lines below.

Fixes the templateMessage item tracked in evolution-foundation#2014. Duplicate of evolution-foundation#2500.
@sourcery-ai

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Adds support for extracting WhatsApp Business template message text in Chatwoot integration so template-based inbound messages correctly create conversations instead of being dropped.

Sequence diagram for handling WhatsApp templateMessage in Chatwoot integration

sequenceDiagram
  participant WhatsApp as WhatsAppPlatform
  participant ChatwootService
  participant ChatwootAPI as ChatwootBackend

  WhatsApp->>ChatwootService: eventWhatsapp(msg)
  ChatwootService->>ChatwootService: getTypeMessage(msg)
  note right of ChatwootService: Extract templateMessage.hydratedTemplate.hydratedContentText
  ChatwootService->>ChatwootService: getContentMessage(types)
  alt body message found
    ChatwootService->>ChatwootAPI: createConversation(body, incoming)
  else no body message found
    ChatwootService->>ChatwootService: eventWhatsapp logs no_body_message_found
  end
Loading

File-Level Changes

Change Details Files
Handle WhatsApp templateMessage payloads during message type extraction so conversations are created for template-based first messages.
  • Extend the type extraction map in getTypeMessage() to include templateMessage content.
  • Populate the templateMessage field using msg.templateMessage.hydratedTemplate.hydratedContentText to mirror existing template handling in the fallback getContentMessage() path.
src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts

Possibly linked issues

  • #unknown: PR adds templateMessage content extraction, directly fixing the Chatwoot templateMessage parsing bug described in the issue.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts" line_range="1781" />
<code_context>
         msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url ||
         msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url ||
         msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url,
+      templateMessage: msg.templateMessage?.hydratedTemplate?.hydratedContentText,
     };

</code_context>
<issue_to_address>
**issue (bug_risk):** Align null-safety with surrounding fields by adding optional chaining on `msg`.

`templateMessage` is the only field that doesn’t use optional chaining on `msg`. If `msg` is ever `undefined`/`null`, `msg.templateMessage…` will throw. Please update this access to `msg?.templateMessage?.hydratedTemplate?.hydratedContentText` to match the surrounding null-safety.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts Outdated
Matches the null-safety style of the surrounding fields in
getTypeMessage() — msg could theoretically be undefined/null.

Addresses sourcery-ai review comment on evolution-foundation#2666.
@abckx-tech

Copy link
Copy Markdown

Production data confirming this — and evidence the same root cause affects more types than templateMessage

We hit this in production and only found it by reconciling the Evolution DB against the Chatwoot DB directly. Sharing the numbers in case they help prioritise.

Setup: evoapicloud/evolution-api:v2.3.7, Chatwoot 4.13.0 (self-built image; no changes to the API-channel ingestion path), Chatwoot integration enabled, 4 instances.

Method: join Message.key.id (Evolution) against messages.source_id (Chatwoot, stored as WAID:<id>). Validated in both directions before trusting it — picked a message the join says arrived and confirmed it exists, and picked several it says are missing and searched Chatwoot by full text content. 1 false positive in 6 sampled, so treat these as upper bounds.

Window: 60 days, inbound only, 1:1 conversations (groups excluded).

message type inbound in Evolution absent from Chatwoot drop rate
templateMessage 37 37 100%
secretEncryptedMessage 24 24 100%
associatedChildMessage 13 13 100%
buttonsMessage 12 12 100%
albumMessage 11 11 100%
interactiveMessage 7 7 100%
placeholderMessage 3 3 100%

templateMessage is 37 for 37 — not a single one produced a conversation, which matches the "no body message found" early-return described here exactly.

The rest of the table is what I think is worth adding to the discussion: every type without a case in getTypeMessage() drops at 100%. So the one-line fix here is correct but treats one symptom of a shared shape — a type the extractor doesn't know silently ends the ingestion path instead of falling through.

Two suggestions, offered as a user rather than a maintainer:

  1. Consider falling back to getContentMessage() (or a generic placeholder) for any unrecognised type, so an unknown type degrades to a conversation with poor content rather than no conversation at all.
  2. Consider logging "no body message found" at warn/error with the message type included. Right now this failure is completely silent from the operator's side: no error surfaces, no queue grows, nothing retries. We ran for weeks without knowing, and the only way we detected it was cross-checking two databases by hand.

Happy to provide more detail from our dataset if useful.

…WhatsApp message types

getTypeMessage() only recognizes a fixed list of message types; anything
else produces no body, logs a bare "no body message found", and the
conversation is silently never created. A user reported this on PR evolution-foundation#2666
with production data (60-day window, 1:1 inbound): every unrecognized
type dropped at 100% — templateMessage (37/37), secretEncryptedMessage
(24/24), associatedChildMessage (13/13), buttonsMessage (12/12),
albumMessage (11/11), interactiveMessage (7/7), placeholderMessage (3/3).
Confirmed the same drop happening on this instance (a live
secretEncryptedMessage silently dropped a few hours after redeploy).

Looked up each type's actual shape in Baileys' WAProto.proto before
deciding how to handle it, rather than a blanket fallback:

 - buttonsMessage: real content field (contentText) was never extracted
 - interactiveMessage: real content field (body.text) was never extracted
 - albumMessage: carries no text by design (WAProto: only
   expectedImageCount/expectedVideoCount) — synthesize an informative
   label instead of a text extractor that could never exist
 - associatedChildMessage: WAProto FutureProofMessage, just wraps a real
   embedded message (e.g. an album's individual photo) — unwrap
   recursively in getConversationMessage() instead of treating it as its
   own type
 - placeholderMessage: WhatsApp-internal only (MASK_LINKED_DEVICES) —
   never real user content, so explicitly skip rather than warn/drop
 - secretEncryptedMessage: encrypted edit/event payload targeting an
   existing message (SecretEncType: EVENT_EDIT/MESSAGE_EDIT), not new
   displayable content — decrypting and applying it as an edit isn't
   implemented, so explicitly skip with a clear reason instead of
   logging a misleading "no body" warning for what isn't a lost message

For any other/future type not covered above, degrade instead of
dropping: create the conversation with a "[mensagem não suportada: X]"
placeholder body and log the actual type name, so a gap like this is
visible from the logs instead of only discoverable by reconciling two
databases by hand (as the reporting user had to do).
…teMessage

Two more common person-to-person message types with no content
extractor in getTypeMessage(), so they'd have fallen into the new
generic placeholder instead of showing real content: poll questions
(pollCreationMessage.name) and group invite links (groupInviteMessage
.caption, falling back to .groupName).
@pbarbagli

Copy link
Copy Markdown
Author

Production data confirming this — and evidence the same root cause affects more types than templateMessage

We hit this in production and only found it by reconciling the Evolution DB against the Chatwoot DB directly. Sharing the numbers in case they help prioritise.

Setup: evoapicloud/evolution-api:v2.3.7, Chatwoot 4.13.0 (self-built image; no changes to the API-channel ingestion path), Chatwoot integration enabled, 4 instances.

Method: join Message.key.id (Evolution) against messages.source_id (Chatwoot, stored as WAID:<id>). Validated in both directions before trusting it — picked a message the join says arrived and confirmed it exists, and picked several it says are missing and searched Chatwoot by full text content. 1 false positive in 6 sampled, so treat these as upper bounds.

Window: 60 days, inbound only, 1:1 conversations (groups excluded).

message type inbound in Evolution absent from Chatwoot drop rate
templateMessage 37 37 100%
secretEncryptedMessage 24 24 100%
associatedChildMessage 13 13 100%
buttonsMessage 12 12 100%
albumMessage 11 11 100%
interactiveMessage 7 7 100%
placeholderMessage 3 3 100%
templateMessage is 37 for 37 — not a single one produced a conversation, which matches the "no body message found" early-return described here exactly.

The rest of the table is what I think is worth adding to the discussion: every type without a case in getTypeMessage() drops at 100%. So the one-line fix here is correct but treats one symptom of a shared shape — a type the extractor doesn't know silently ends the ingestion path instead of falling through.

Two suggestions, offered as a user rather than a maintainer:

  1. Consider falling back to getContentMessage() (or a generic placeholder) for any unrecognised type, so an unknown type degrades to a conversation with poor content rather than no conversation at all.
  2. Consider logging "no body message found" at warn/error with the message type included. Right now this failure is completely silent from the operator's side: no error surfaces, no queue grows, nothing retries. We ran for weeks without knowing, and the only way we detected it was cross-checking two databases by hand.

Happy to provide more detail from our dataset if useful.

Thanks @abckx-tech for the production data — really useful. We hit the same thing on our own instance (a secretEncryptedMessage got silently dropped a few hours after we noticed this thread), so I went ahead and broadened this PR to cover the full pattern instead of just templateMessage.
Looked up each type's actual shape in Baileys' WAProto.proto before deciding how to handle it, since a blanket fallback would've been wrong for a couple of these:
buttonsMessage / interactiveMessage: these actually carry real text (contentText / body.text) that just wasn't being extracted — added real extraction, not a placeholder.
albumMessage: carries no text by design (WAProto only has expectedImageCount/expectedVideoCount — the photos arrive as separate messages). Synthesizes an informative label instead.
associatedChildMessage: it's just a FutureProofMessage envelope wrapping a real embedded message (e.g. an album's individual photo) — now unwrapped recursively instead of treated as its own unknown type.
placeholderMessage: this one is WhatsApp-internal only (MASK_LINKED_DEVICES), never real user content — explicitly skipped rather than warned/dropped, so it stops padding the "silent drop" stats.
secretEncryptedMessage: it's an encrypted edit/event payload targeting an existing message (SecretEncType.EVENT_EDIT/MESSAGE_EDIT), not new content — decrypting and applying it as an edit is a separate, bigger piece of work I didn't attempt here, so it's explicitly skipped with a clear reason instead of logging a misleading "no body" warning.
Also added pollCreationMessage and groupInviteMessage — two more common person-to-person types with no extractor.
For anything else not covered above (current or future WhatsApp message types), instead of silently dropping we now create the conversation with a [mensagem não suportada: ] placeholder and log the actual type name — so a gap like this one is visible from the logs instead of only discoverable by reconciling two databases by hand.
Tested against our own instance (tsc/eslint clean, deployed and monitoring). Happy to adjust based on maintainer feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants